aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/settings')
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx6
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx55
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx93
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx102
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/page.tsx12
11 files changed, 539 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
new file mode 100644
index 0000000..468f250
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
@@ -0,0 +1,6 @@
+'use client';
+import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
+
+export function SettingsPage({ websiteId }: { websiteId: string }) {
+ return <WebsiteSettingsPage websiteId={websiteId} />;
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
new file mode 100644
index 0000000..21cd613
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
@@ -0,0 +1,104 @@
+import { Button, Column, Dialog, DialogTrigger, Modal } from '@umami/react-zen';
+import { ActionForm } from '@/components/common/ActionForm';
+import {
+ useLoginQuery,
+ useMessages,
+ useModified,
+ useNavigation,
+ useUserTeamsQuery,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { WebsiteDeleteForm } from './WebsiteDeleteForm';
+import { WebsiteResetForm } from './WebsiteResetForm';
+import { WebsiteTransferForm } from './WebsiteTransferForm';
+
+export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { user } = useLoginQuery();
+ const { touch } = useModified();
+ const { router, pathname, teamId, renderUrl } = useNavigation();
+ const { data: teams } = useUserTeamsQuery(user.id);
+ const isAdmin = pathname.startsWith('/admin');
+
+ const canTransferWebsite =
+ (
+ (!teamId &&
+ teams?.data?.filter(({ members }) =>
+ members.find(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ )) ||
+ []
+ ).length > 0 ||
+ (teamId &&
+ !!teams?.data
+ ?.find(({ id }) => id === teamId)
+ ?.members.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));
+
+ const handleSave = () => {
+ touch('websites');
+ onSave?.();
+ router.push(renderUrl(`/websites`));
+ };
+
+ const handleReset = async () => {
+ onSave?.();
+ };
+
+ return (
+ <Column gap="6">
+ {!isAdmin && (
+ <ActionForm
+ label={formatMessage(labels.transferWebsite)}
+ description={formatMessage(messages.transferWebsite)}
+ >
+ <DialogTrigger>
+ <Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.transferWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteTransferForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ )}
+
+ <ActionForm
+ label={formatMessage(labels.resetWebsite)}
+ description={formatMessage(messages.resetWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button>{formatMessage(labels.reset)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.resetWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+
+ <ActionForm
+ label={formatMessage(labels.deleteWebsite)}
+ description={formatMessage(messages.deleteWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button data-test="button-delete" variant="danger">
+ {formatMessage(labels.delete)}
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteDeleteForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
new file mode 100644
index 0000000..2fc0276
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
@@ -0,0 +1,40 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'DELETE';
+
+export function WebsiteDeleteForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/websites/${websiteId}`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('websites');
+ touch(`websites:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
new file mode 100644
index 0000000..4ae819e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
@@ -0,0 +1,55 @@
+import { Form, FormButtons, FormField, FormSubmitButton, TextField } from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks';
+import { DOMAIN_REGEX } from '@/lib/constants';
+
+export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const website = useWebsite();
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('websites');
+ touch(`website:${website.id}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={website}>
+ <FormField name="id" label={formatMessage(labels.websiteId)}>
+ <TextField data-test="text-field-websiteId" value={website?.id} isReadOnly allowCopy />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.name)}
+ data-test="input-name"
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.domain)}
+ data-test="input-domain"
+ name="domain"
+ rules={{
+ required: formatMessage(labels.required),
+ pattern: {
+ value: DOMAIN_REGEX,
+ message: formatMessage(messages.invalidDomain),
+ },
+ }}
+ >
+ <TextField />
+ </FormField>
+ <FormButtons>
+ <FormSubmitButton data-test="button-submit" variant="primary">
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
new file mode 100644
index 0000000..d791bc9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
@@ -0,0 +1,37 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'RESET';
+
+export function WebsiteResetForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error } = useUpdateQuery(`/websites/${websiteId}/reset`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.reset)}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
new file mode 100644
index 0000000..3970cdb
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -0,0 +1,28 @@
+import { Column } from '@umami/react-zen';
+import { Panel } from '@/components/common/Panel';
+import { useWebsite } from '@/components/hooks';
+import { WebsiteData } from './WebsiteData';
+import { WebsiteEditForm } from './WebsiteEditForm';
+import { WebsiteShareForm } from './WebsiteShareForm';
+import { WebsiteTrackingCode } from './WebsiteTrackingCode';
+
+export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
+ const website = useWebsite();
+
+ return (
+ <Column gap="6">
+ <Panel>
+ <WebsiteEditForm websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteTrackingCode websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
+ </Panel>
+ <Panel>
+ <WebsiteData websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
new file mode 100644
index 0000000..99977a0
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
@@ -0,0 +1,22 @@
+import { IconLabel, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { ArrowLeft, Globe } from '@/components/icons';
+
+export function WebsiteSettingsHeader() {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+
+ return (
+ <>
+ <Row marginTop="6">
+ <Link href={renderUrl(`/websites/${website.id}`)}>
+ <IconLabel icon={<ArrowLeft />} label={formatMessage(labels.website)} />
+ </Link>
+ </Row>
+ <PageHeader title={website?.name} description={website?.domain} icon={<Globe />} />
+ </>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
new file mode 100644
index 0000000..56c6f43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
@@ -0,0 +1,93 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormSubmitButton,
+ IconLabel,
+ Label,
+ Row,
+ Switch,
+ TextField,
+} from '@umami/react-zen';
+import { RefreshCcw } from 'lucide-react';
+import { useState } from 'react';
+import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => getRandomChars(16);
+
+export interface WebsiteShareFormProps {
+ websiteId: string;
+ shareId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}
+
+export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const [currentId, setCurrentId] = useState(shareId);
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+ const { cloudMode } = useConfig();
+
+ const getUrl = (shareId: string) => {
+ if (cloudMode) {
+ return `${process.env.cloudUrl}/share/${shareId}`;
+ }
+
+ return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
+ };
+
+ const url = getUrl(currentId);
+
+ const handleGenerate = () => {
+ setCurrentId(generateId());
+ };
+
+ const handleSwitch = () => {
+ setCurrentId(currentId ? null : generateId());
+ };
+
+ const handleSave = async () => {
+ const data = {
+ shareId: currentId,
+ };
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch(`website:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
+ <Column gap>
+ <Switch isSelected={!!currentId} onChange={handleSwitch}>
+ {formatMessage(labels.enableShareUrl)}
+ </Switch>
+ {currentId && (
+ <Row alignItems="flex-end" gap>
+ <Column flexGrow={1}>
+ <Label>{formatMessage(labels.shareUrl)}</Label>
+ <TextField value={url} isReadOnly allowCopy />
+ </Column>
+ <Column>
+ <Button onPress={handleGenerate}>
+ <IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
+ </Button>
+ </Column>
+ </Row>
+ )}
+ <FormButtons justifyContent="flex-end">
+ <Row alignItems="center" gap>
+ {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
+ <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </FormButtons>
+ </Column>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
new file mode 100644
index 0000000..d24f948
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
@@ -0,0 +1,40 @@
+import { Column, Label, Text, TextField } from '@umami/react-zen';
+import { useConfig, useMessages } from '@/components/hooks';
+
+const SCRIPT_NAME = 'script.js';
+
+export function WebsiteTrackingCode({
+ websiteId,
+ hostUrl,
+}: {
+ websiteId: string;
+ hostUrl?: string;
+}) {
+ const { formatMessage, messages, labels } = useMessages();
+ const config = useConfig();
+
+ const trackerScriptName =
+ config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
+
+ const getUrl = () => {
+ if (config?.cloudMode) {
+ return `${process.env.cloudUrl}/${trackerScriptName}`;
+ }
+
+ return `${hostUrl || window?.location?.origin || ''}${
+ process.env.basePath || ''
+ }/${trackerScriptName}`;
+ };
+
+ const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl();
+
+ const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
+
+ return (
+ <Column gap>
+ <Label>{formatMessage(labels.trackingCode)}</Label>
+ <Text color="muted">{formatMessage(messages.trackingCode)}</Text>
+ <TextField value={code} isReadOnly allowCopy asTextArea resize="none" />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
new file mode 100644
index 0000000..8af4f05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
@@ -0,0 +1,102 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ Loading,
+ Select,
+ Text,
+} from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import {
+ useLoginQuery,
+ useMessages,
+ useUpdateQuery,
+ useUserTeamsQuery,
+ useWebsite,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function WebsiteTransferForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { user } = useLoginQuery();
+ const website = useWebsite();
+ const [teamId, setTeamId] = useState<string>(null);
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`);
+ const { data: teams, isLoading } = useUserTeamsQuery(user.id);
+ const isTeamWebsite = !!website?.teamId;
+
+ const items =
+ teams?.data?.filter(({ members }) =>
+ members.some(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ ) || [];
+
+ const handleSubmit = async () => {
+ await mutateAsync(
+ {
+ userId: website.teamId ? user.id : undefined,
+ teamId: website.userId ? teamId : undefined,
+ },
+ {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ const handleChange = (key: Key) => {
+ setTeamId(key as string);
+ };
+
+ if (isLoading) {
+ return <Loading icon="dots" placement="center" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={{ teamId }}>
+ <Text>
+ {formatMessage(
+ isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam,
+ )}
+ </Text>
+ <FormField name="teamId">
+ {!isTeamWebsite && (
+ <Select onSelectionChange={handleChange} selectedKey={teamId}>
+ {items.map(({ id, name }) => {
+ return (
+ <ListItem key={`${id}`} id={`${id}`}>
+ {name}
+ </ListItem>
+ );
+ })}
+ </Select>
+ )}
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton
+ variant="primary"
+ isPending={isPending}
+ isDisabled={!isTeamWebsite && !teamId}
+ >
+ {formatMessage(labels.transfer)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx
new file mode 100644
index 0000000..a26d14f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SettingsPage } from './SettingsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SettingsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Settings',
+};